# Copyright (C) 2008-2010 The Written Word, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# $Id: xmlutil.py 728 2010-07-19 20:34:04Z china $

import cgi
import os
import re
import sys

from textwrap import fill
from types import StringType, UnicodeType

# This way, we can adjust the path in just this file (above), and
# everything else can import safely from here.  This also enforces
# a hygienic decoupling of lxml from the rest of the software:
from lxml import etree, objectify
from lxml.etree import XMLSyntaxError

class XMLError (Exception):
  """Base class for all exceptions raised by this module"""

  def __init__ (self, msg = None):
    self.msg = msg

  def __str__ (self):
    return self.msg

class XMLDTDError (XMLError):
  """DTD validation error"""

class XMLParseError (XMLError):
  """XML parsing error"""


# When xml_path has a DOCTYPE declaration, extract a DTD path from it.
# Return an absolute path as is, or else search dtd_dirs for the first
# matching relative path and return that.
def dtd_search (dtd_dirs, xml_path, default):
  dtd_regex = re.compile ('<!DOCTYPE .* SYSTEM "(file://)?([^"]+)"')
  dtd_maybe = None

  if type (xml_path) in (StringType, UnicodeType):
    # Normalize, and strip leading 'file://'
    match = re.match ('file://(.*)$', xml_path)
    if match:
      xml_path = match.group (1)
    xml_path = os.path.normpath (xml_path)

    fh = open (xml_path, 'r')
  else:
    # xml_path must be an open file handle
    fh = xml_path

    # save the current read position, but scan from the beginning
    filepos = fh.tell ()
    fh.seek (0)

  # Optimise for well-formed db, where all files have a
  # DOCTYPE declaration in the first few lines
  while 1:
    aline = fh.readline ()
    if not aline:
      # no DOCTYPE thru EOF
      dtd_maybe = default
      break
    match = dtd_regex.search (aline)
    if match:
      # exit loop with first match
      dtd_maybe = match.group (2)
      break

  if type (xml_path) in (StringType, UnicodeType):
    # close the handle opened by this function
    fh.close ()
  else:
    # reset the read position back to where it was originally
    fh.seek (filepos)

  # when dtd is a relative path, search for first match in dtd_dirs
  if not dtd_maybe.startswith ('/'):
    for dir in dtd_dirs:
      path = os.path.join (dir, dtd_maybe)
      if os.path.exists (path):
        dtd_maybe = path
        break

  return dtd_maybe

class DTD:
  """All instances of this class share a cache of parsed DTD documents.
  Whenever a DTD validation is performed, the class instance uses the
  previously cached DTD object in preference to parsing that DTD
  again."""

  cache = {}

  def __init__ (self, path):
    self.path = path
    if not path in DTD.cache:
      DTD.cache[path] = etree.DTD (path)

  def error_log (self):
    return DTD.cache[self.path].error_log

  def validate (self, xml):
    return DTD.cache[self.path].validate (xml)

# An etree pretty printer tailored to TWW .sb output.
#
# To keep the algorithm as simple as possible we make several
# assumptions:
#   (i) The names of the tags that require a leading newline
#       are hardcoded;
#  (ii) Elements can have either child elements or contain
#       some text (the text is not printed for an element that
#       has both);
# (iii) The right margin is soft - we don't break the text to
#       stay inside it unless there is a space already, and
#       we don't break attribute contents at all;
#  (iv) Any additional tail text is ignored.
#
# This is far from a generalized xml pretty printer, so don't
# use it for anything else without being aware of the above!!
###
def pprint (elem, margin=0, file=sys.stdout, level=0):
  indent = level*'  '

  lines = ppattributes (elem, margin, indent)
  line = lines.pop (0)

  # some tags traditionally follow a blank line:
  if elem.tag in ['dependencies', 'install-name', 'module']:
    file.write ('\n')

  # write all but the last line
  while len (lines):
    file.write (line + '\n')
    line = lines.pop (0)

  # First, deal with elements that have children:
  if len (elem):
    file.write (line + '>\n')
    for e in elem:
      pprint (e, margin, file, level+1)
    file.write (indent + '</' + elem.tag + '>\n')

  # Otherwise, containing text instead of children:
  elif elem.text is not None:
    text = cgi.escape (elem.text.strip ())
    oneline = line + '>' + text + '</' + elem.tag + '>'
    # ...fits on one line or has no spaces...
    if len (oneline) <= margin or oneline.find(' ') == -1:
      file.write (oneline)
    # ...split between attributes where possible...
    elif line.find(' ') != -1:
      attrs = line.rsplit(' ', 1)
      twolines = attrs[0] + '\n' + indent + '  ' + attrs[1]
      file.write (twolines + '>' + text + '</' + elem.tag + '>')
    # ...text alone fits on a single line...
    elif (len (text + indent) + 2) < margin:
      file.write (line + '>\n')
      file.write (indent + '  ' + text + '\n')
      file.write (indent + '</' + elem.tag + '>')
    # ...otherwise use a left justified fill...
    else:
      file.write (line + '>' + fill (text, margin) + '\n<' + elem.tag + '>')
    file.write ('\n')

  # And finally, elements with no children and no text:
  else:
    file.write (line + '/>\n')

# Return a list of lines constituting an opening tag with all attributes,
# no closing '>' is appended incase caller wants to use '/>' shorthand.
def ppattributes (elem, margin, indent=''):
    result = []
    line = indent + '<' + elem.tag

    for name in elem.attrib.keys ():
      attribute = name + '="' + elem.attrib[name] + '"'

      # Continue on the same line if it fits inside the margin...
      if (len (line + ' ' + attribute)) < margin:
        line += ' ' + attribute

      # ...otherwise, start a new line also indented some more.
      else:
	result.append (line)
        line = indent + '  ' + attribute

    result.append (line)

    return result
